Managing Loading, Error, and Empty States in Flutter
When a Flutter application communicates with an API, the UI must handle different situations such as waiting for data, successfully receiving data, receiving an empty result, or encountering an error. Managing these states properly makes an application more reliable, user-friendly, and easier to maintain.
Flutter's FutureBuilder can be used to build UI according to the state of an asynchronous operation, including loading and error states. For larger applications, state-management approaches such as ChangeNotifier can also represent loading, success, and error states.
1. What Are UI States?
A state represents the current condition of data or an operation in the application. When working with APIs, a screen commonly needs to represent four primary states:
- Loading: Data is currently being requested.
- Success: Data has been received successfully.
- Empty: The request succeeded, but there is no data to display.
- Error: The request failed or the received data could not be processed.
2. Why State Management Is Important
Without proper state handling, users may see a blank screen, an endlessly spinning loader, confusing error messages, or an outdated result. A well-designed screen clearly communicates what is happening.
| State | Meaning | Typical UI |
| Loading | API request is in progress | CircularProgressIndicator |
| Success | Valid data is available | List, cards, details, etc. |
| Empty | Request succeeded but contains no usable records | No data message |
| Error | Request or processing failed | Error message and Retry button |
3. Loading State
The loading state is displayed while Flutter waits for an asynchronous operation to complete. A common loading widget is CircularProgressIndicator.
const Center(
child: CircularProgressIndicator(),
)
4. Why Show a Loading Indicator?
- It tells the user that the application is working.
- It prevents the screen from appearing frozen.
- It provides feedback during slow network operations.
- It improves the perceived responsiveness of the application.
- It clearly separates the waiting state from the result state.
5. Simple Loading Example
bool isLoading = true;
Widget buildContent() {
if (isLoading) {
return const Center(
child: CircularProgressIndicator(),
);
}
return const Text('Data loaded');
}
6. Loading State with FutureBuilder
FutureBuilder is a built-in Flutter widget that makes it convenient to build UI from asynchronous data. The Future is supplied to the widget, and the builder decides what to display according to the Future's state.
FutureBuilder(
future: fetchData(),
builder: (context, snapshot) {
if (snapshot.connectionState == ConnectionState.waiting) {
return const Center(
child: CircularProgressIndicator(),
);
}
return const Text('Data loaded');
},
)
7. Understanding ConnectionState
The connectionState property can help determine the current stage of a Future.
| ConnectionState | Meaning | Common UI |
| none | No asynchronous operation is connected | Initial or empty content |
| waiting | Future is waiting for completion | Loading indicator |
| active | Asynchronous operation is active | Progress-related UI |
| done | Future has completed | Success or error UI |
8. Error State
An error state is displayed when an API request, network operation, JSON parsing operation, authentication process, or another asynchronous task fails.
Common API-related errors include:
- No internet connection
- Invalid API URL
- Server error
- Authentication failure
- Request timeout
- Invalid response format
- JSON parsing error
- Requested resource not found
9. Checking for Errors with FutureBuilder
snapshot.hasError can be used to determine whether the Future completed with an error.
FutureBuilder(
future: fetchData(),
builder: (context, snapshot) {
if (snapshot.hasError) {
return const Text(
'Something went wrong.',
);
}
if (snapshot.hasData) {
return Text(snapshot.data!);
}
return const CircularProgressIndicator();
},
)
10. User-Friendly Error Messages
Technical error information is useful for developers, but users should normally receive a simple and understandable message.
if (snapshot.hasError) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Icon(
Icons.error_outline,
size: 50,
),
const SizedBox(height: 12),
const Text(
'Unable to load data.',
style: TextStyle(fontSize: 18),
),
const SizedBox(height: 10),
ElevatedButton(
onPressed: retry,
child: const Text('Try Again'),
),
],
),
);
}
11. Retry Button
A Retry button allows the user to repeat a failed API operation without leaving the screen.
void retry() {
setState(() {
dataFuture = fetchData();
});
}
The button can then call the retry method:
ElevatedButton(
onPressed: retry,
child: const Text('Try Again'),
)
12. Empty State
An empty state occurs when an API request completes successfully but there is no data to display. An empty result is different from an error.
For example, a product-search API might successfully return an empty list because there are no products matching the search.
if (users.isEmpty) {
return const Center(
child: Text('No users found.'),
);
}
13. Why Empty State Is Important
- It tells the user that the request succeeded.
- It prevents a blank screen from appearing.
- It distinguishes "no data" from "request failed".
- It can provide useful next actions.
- It improves the overall user experience.
14. Empty State with an Icon
Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Icon(
Icons.inbox_outlined,
size: 60,
),
const SizedBox(height: 12),
const Text(
'No items available.',
style: TextStyle(fontSize: 18),
),
],
),
)
15. Empty State with an Action
Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Icon(
Icons.search_off,
size: 60,
),
const SizedBox(height: 12),
const Text('No results found.'),
const SizedBox(height: 12),
ElevatedButton(
onPressed: clearSearch,
child: const Text('Clear Search'),
),
],
),
)
16. Loading, Success, Empty, and Error Flow
Start
|
v
Loading
|
v
API Request
|
+-------------------+
| |
v v
Success Error
| |
v v
Has Data? Show Error
|
+----------+
| |
v v
Yes No
| |
v v
Display Empty State
Data
17. Complete FutureBuilder State Handling
FutureBuilder<List<String>>(
future: itemsFuture,
builder: (context, snapshot) {
if (snapshot.connectionState == ConnectionState.waiting) {
return const Center(
child: CircularProgressIndicator(),
);
}
if (snapshot.hasError) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Text('Unable to load items.'),
const SizedBox(height: 10),
ElevatedButton(
onPressed: retry,
child: const Text('Retry'),
),
],
),
);
}
if (!snapshot.hasData || snapshot.data!.isEmpty) {
return const Center(
child: Text('No items available.'),
);
}
final items = snapshot.data!;
return ListView.builder(
itemCount: items.length,
itemBuilder: (context, index) {
return ListTile(
title: Text(items[index]),
);
},
);
},
)
18. Why Check Error Before Empty Data?
Error and empty states represent different situations. An error means the operation failed, while an empty state means the operation completed but there is no content to show.
if (snapshot.hasError) {
return const Text('Failed to load data');
}
if (!snapshot.hasData || snapshot.data!.isEmpty) {
return const Text('No data available');
}
19. API Service Example
import 'dart:convert';
import 'package:http/http.dart' as http;
Future<List<String>> fetchUsers() async {
final response = await http.get(
Uri.parse(
'https://jsonplaceholder.typicode.com/users',
),
);
if (response.statusCode == 200) {
final data = jsonDecode(response.body) as List;
return data
.map(
(user) => user['name'] as String,
)
.toList();
}
throw Exception(
'Failed to load users',
);
}
20. Complete StatefulWidget Example
import 'package:flutter/material.dart';
class UsersPage extends StatefulWidget {
const UsersPage({super.key});
@override
State<UsersPage> createState() => _UsersPageState();
}
class _UsersPageState extends State<UsersPage> {
late Future<List<String>> usersFuture;
@override
void initState() {
super.initState();
usersFuture = fetchUsers();
}
void retry() {
setState(() {
usersFuture = fetchUsers();
});
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('Users'),
),
body: FutureBuilder<List<String>>(
future: usersFuture,
builder: (context, snapshot) {
if (snapshot.connectionState == ConnectionState.waiting) {
return const Center(
child: CircularProgressIndicator(),
);
}
if (snapshot.hasError) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Icon(
Icons.error_outline,
size: 50,
),
const SizedBox(height: 12),
const Text(
'Unable to load users.',
),
const SizedBox(height: 12),
ElevatedButton(
onPressed: retry,
child: const Text('Retry'),
),
],
),
);
}
final users = snapshot.data ?? [];
if (users.isEmpty) {
return const Center(
child: Text(
'No users found.',
),
);
}
return ListView.builder(
itemCount: users.length,
itemBuilder: (context, index) {
return ListTile(
leading: CircleAvatar(
child: Text(
users[index][0],
),
),
title: Text(users[index]),
);
},
);
},
),
);
}
}
21. Avoid Calling APIs Inside build()
API calls should generally not be created directly inside the build() method. Flutter can call build() multiple times, which can cause the request to execute repeatedly.
A common approach is to initialize the Future in initState() and store it in a state variable.
late Future<List<String>> usersFuture;
@override
void initState() {
super.initState();
usersFuture = fetchUsers();
}
22. Handling API Errors with try-catch
Future<List<String>> fetchUsers() async {
try {
final response = await http.get(
Uri.parse(
'https://example.com/api/users',
),
);
if (response.statusCode == 200) {
final data = jsonDecode(response.body) as List;
return data
.map(
(item) => item['name'] as String,
)
.toList();
}
throw Exception(
'Server returned ${response.statusCode}',
);
} catch (e) {
throw Exception(
'Unable to fetch users',
);
}
}
23. Handling Different HTTP Errors
if (response.statusCode == 200) {
return parseUsers(response.body);
}
if (response.statusCode == 401) {
throw Exception(
'Authentication required',
);
}
if (response.statusCode == 404) {
throw Exception(
'Resource not found',
);
}
if (response.statusCode >= 500) {
throw Exception(
'Server error',
);
}
throw Exception(
'Unexpected response',
);
24. Creating Reusable Loading Widget
When many screens use the same loading UI, creating a reusable widget can reduce duplicated code.
class LoadingWidget extends StatelessWidget {
const LoadingWidget({super.key});
@override
Widget build(BuildContext context) {
return const Center(
child: CircularProgressIndicator(),
);
}
}
Usage:
if (isLoading) {
return const LoadingWidget();
}
25. Creating a Reusable Error Widget
class ErrorWidgetView extends StatelessWidget {
final String message;
final VoidCallback onRetry;
const ErrorWidgetView({
super.key,
required this.message,
required this.onRetry,
});
@override
Widget build(BuildContext context) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Icon(
Icons.error_outline,
size: 50,
),
const SizedBox(height: 10),
Text(message),
const SizedBox(height: 10),
ElevatedButton(
onPressed: onRetry,
child: const Text('Retry'),
),
],
),
);
}
}
26. Creating a Reusable Empty Widget
class EmptyStateWidget extends StatelessWidget {
final String message;
const EmptyStateWidget({
super.key,
required this.message,
});
@override
Widget build(BuildContext context) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Icon(
Icons.inbox_outlined,
size: 60,
),
const SizedBox(height: 12),
Text(message),
],
),
);
}
}
27. Using the Reusable Widgets
if (isLoading) {
return const LoadingWidget();
}
if (hasError) {
return ErrorWidgetView(
message: 'Unable to load products.',
onRetry: loadProducts,
);
}
if (products.isEmpty) {
return const EmptyStateWidget(
message: 'No products available.',
);
}
return ProductList(
products: products,
);
28. Managing State with Boolean Variables
A small application may use boolean variables to represent state.
bool isLoading = false;
bool hasError = false;
List<String> items = [];
However, using multiple booleans can create confusing combinations. For example, both isLoading and hasError could accidentally become true. For more complex applications, a single explicit state representation can be easier to maintain.
29. Using an Enum for API State
enum ApiState {
loading,
success,
empty,
error,
}
Now the screen can store one primary state:
ApiState state = ApiState.loading;
30. Building UI from an Enum State
Widget buildContent() {
switch (state) {
case ApiState.loading:
return const LoadingWidget();
case ApiState.success:
return const Text('Data loaded');
case ApiState.empty:
return const EmptyStateWidget(
message: 'No data found',
);
case ApiState.error:
return ErrorWidgetView(
message: 'Unable to load data',
onRetry: loadData,
);
}
}
31. State Management with ChangeNotifier
For larger applications, loading and error states can be managed in a ViewModel or another state-management layer. ChangeNotifier can expose properties such as loading status, data, and errors, followed by notifyListeners() so listening widgets can rebuild.
class UserViewModel extends ChangeNotifier {
bool isLoading = false;
List<String> users = [];
Exception? error;
Future<void> loadUsers() async {
isLoading = true;
error = null;
notifyListeners();
try {
users = await fetchUsers();
} catch (e) {
error = Exception(
'Unable to load users',
);
} finally {
isLoading = false;
notifyListeners();
}
}
}
32. Displaying ViewModel States
if (viewModel.isLoading) {
return const LoadingWidget();
}
if (viewModel.error != null) {
return ErrorWidgetView(
message: 'Unable to load users.',
onRetry: viewModel.loadUsers,
);
}
if (viewModel.users.isEmpty) {
return const EmptyStateWidget(
message: 'No users found.',
);
}
return UserList(
users: viewModel.users,
);
33. Loading During Refresh
Initial loading and refreshing existing data can sometimes require different UI behavior. During the initial request, a full-screen loader may be appropriate. During a refresh, keeping existing content visible while showing a smaller progress indicator can provide a smoother experience.
Column(
children: [
if (isRefreshing)
const LinearProgressIndicator(),
Expanded(
child: UserList(
users: users,
),
),
],
)
34. Pull-to-Refresh
RefreshIndicator can be used to allow users to pull a list downward to refresh its data.
RefreshIndicator(
onRefresh: loadUsers,
child: ListView.builder(
itemCount: users.length,
itemBuilder: (context, index) {
return ListTile(
title: Text(users[index]),
);
},
),
)
35. Empty Search Results
Search screens often require a special empty state. The user may have successfully searched the server, but no matching records may exist.
if (searchResults.isEmpty) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Icon(
Icons.search_off,
size: 60,
),
const SizedBox(height: 12),
Text(
'No results for "$query"',
),
const SizedBox(height: 12),
ElevatedButton(
onPressed: clearSearch,
child: const Text('Clear Search'),
),
],
),
);
}
36. Empty State vs Error State
| Empty State | Error State |
| Request completed successfully | Request failed |
| No matching data exists | Data could not be retrieved |
| Usually no technical problem | Usually requires error handling |
| May provide search/filter actions | Usually provides Retry |
| Example: "No products found" | Example: "Unable to load products" |
37. Avoiding Infinite Loading
A common problem is displaying a loading indicator indefinitely. The asynchronous operation should complete with either data or an error. When a Future is expected to produce data, make sure the asynchronous operation reaches a completed state with a meaningful result or error rather than leaving the UI waiting indefinitely.
Future<String> fetchData() async {
final response = await http.get(
Uri.parse('https://example.com/data'),
);
if (response.statusCode == 200) {
return response.body;
}
throw Exception(
'Failed to load data',
);
}
38. Using finally for Loading State
The finally block is useful for ensuring that the loading state is reset whether the operation succeeds or fails.
Future<void> loadData() async {
setState(() {
isLoading = true;
});
try {
data = await fetchData();
} catch (e) {
errorMessage = 'Unable to load data';
} finally {
if (mounted) {
setState(() {
isLoading = false;
});
}
}
}
39. Complete State Management Example
enum ApiState {
loading,
success,
empty,
error,
}
class ProductsPage extends StatefulWidget {
const ProductsPage({super.key});
@override
State<ProductsPage> createState() => _ProductsPageState();
}
class _ProductsPageState extends State<ProductsPage> {
ApiState state = ApiState.loading;
List<String> products = [];
@override
void initState() {
super.initState();
loadProducts();
}
Future<void> loadProducts() async {
setState(() {
state = ApiState.loading;
});
try {
final result = await fetchProducts();
if (!mounted) {
return;
}
if (result.isEmpty) {
setState(() {
products = [];
state = ApiState.empty;
});
} else {
setState(() {
products = result;
state = ApiState.success;
});
}
} catch (e) {
if (!mounted) {
return;
}
setState(() {
state = ApiState.error;
});
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('Products'),
),
body: switch (state) {
ApiState.loading => const LoadingWidget(),
ApiState.error => ErrorWidgetView(
message: 'Unable to load products.',
onRetry: loadProducts,
),
ApiState.empty => const EmptyStateWidget(
message: 'No products available.',
),
ApiState.success => ListView.builder(
itemCount: products.length,
itemBuilder: (context, index) {
return ListTile(
title: Text(products[index]),
);
},
),
},
);
}
}
40. State Management Best Practices
- Always provide feedback while asynchronous data is loading.
- Always handle API failures.
- Differentiate an empty response from an error.
- Provide a Retry action for recoverable errors.
- Use simple and user-friendly error messages.
- Keep API logic separate from presentation logic when possible.
- Avoid starting API requests repeatedly inside
build().
- Use
FutureBuilder for simple Future-based screens.
- Use a structured state-management approach for larger applications.
- Clear stale errors when starting a new request.
- Reset loading state after both successful and failed requests.
- Disable duplicate actions while an operation is running.
- Provide meaningful empty-state actions where appropriate.
- Keep existing data visible during refresh when that provides a better user experience.
41. Common Mistakes
- Showing a blank screen while data loads.
- Showing a loading spinner forever.
- Treating an empty list as an API error.
- Showing raw technical exceptions to users.
- Forgetting to provide a Retry button.
- Calling APIs repeatedly inside
build().
- Not resetting the loading flag after an exception.
- Using multiple unrelated boolean variables that can produce contradictory states.
- Ignoring invalid or unexpected API responses.
- Not handling empty search results.
42. Practical API State Flow
Application Starts
|
v
Loading State
|
v
Send API Request
|
v
Receive Response
|
+----+----+
| |
Success Error
| |
v v
Check Data Error UI
|
+--+--+
| |
Data Empty
| |
v v
UI Empty UI
43. Interview Questions
- What is a loading state in Flutter?
- Why should an application display a loading indicator?
- What is an error state?
- What is an empty state?
- What is the difference between an empty state and an error state?
- How does
FutureBuilder handle asynchronous data?
- What is
snapshot.hasError?
- What is
snapshot.hasData?
- What is
ConnectionState.waiting?
- Why should API calls generally not be made inside
build()?
- How can you implement a Retry button?
- How can you handle an empty API response?
- Why is
finally useful for loading states?
- How can
ChangeNotifier be used to manage loading and error states?
- Why can an enum be useful for representing API states?
44. Practice Exercise
Create a Flutter application that displays a list of products from an API.
- Create a Flutter project.
- Add the
http package.
- Create a model for the product.
- Create an API service.
- Make a GET request.
- Show a loading indicator while the request is running.
- Display products after a successful response.
- Display an empty-state widget when the API returns an empty list.
- Display an error widget when the request fails.
- Add a Retry button.
- Add pull-to-refresh functionality.
- Add a search field.
- Display a separate empty state when a search returns no results.
- Prevent duplicate requests while loading.
45. Quick Revision Table
| Concept | Purpose |
| Loading State | Shows that an asynchronous operation is in progress |
| Success State | Displays successfully received data |
| Empty State | Shows that there is no data to display |
| Error State | Shows that an operation failed |
| FutureBuilder | Builds UI from a Future's state |
| snapshot.hasData | Checks whether non-null data is available |
| snapshot.hasError | Checks whether the Future completed with an error |
| ConnectionState.waiting | Indicates that the Future is still waiting |
| Retry | Starts the operation again after failure |
| ChangeNotifier | Can notify listening widgets when state changes |
| notifyListeners() | Signals listeners to rebuild using updated state |
46. Learning Outcome
After completing this topic, you should be able to:
- Understand loading, success, empty, and error states.
- Build asynchronous UI using
FutureBuilder.
- Display meaningful loading indicators.
- Handle API and network errors.
- Create user-friendly error and empty-state widgets.
- Implement Retry functionality.
- Prevent unnecessary API calls inside
build().
- Use enums to represent application states.
- Use
ChangeNotifier for structured state handling.
- Implement pull-to-refresh functionality.
- Design reusable state widgets for larger applications.
47. Summary
Managing loading, error, and empty states is an essential part of building reliable Flutter applications. A good API-driven screen should clearly represent what is happening at every stage of the request.
The basic flow is Loading → Success/Empty/Error. FutureBuilder is useful for simple Future-based asynchronous operations, while a structured state-management approach such as ChangeNotifier can be useful when an application becomes more complex.
By separating API logic from UI logic, providing meaningful feedback, handling failures, and designing clear empty states, Flutter applications become easier to understand and maintain.
48. Useful Resources
49. JustAcademy Flutter Training
Learn Flutter development through structured practical training covering Dart programming, Flutter UI development, API integration, Firebase, state management, and application development.
Explore JustAcademy Flutter Training Course
Register for Flutter Course Demo